가상 스크롤을 썼는데도 채팅방이 느렸던 이유
2026-05-29
이번 작업 전후의 모습은 아래 세 장면에서 가장 잘 보인다. 각 이미지의 왼쪽이 개선 전, 오른쪽이 개선 후다.
React error #185가 발생한다.개선 후 — 가상 렌더링과 캐싱을 적용해 데이터를 아무리 더 불러와도 DOM 노드 수가 늘지 않는다.
개선 후 — 출력 속도를 일정하게 유지하고 스크롤 위치를 안정적으로 고정했다.
개선 후 — 커서 기반 양방향 페이지네이션으로 첫 메시지까지 즉시 이동한다.
베이비챗에서 사용자가 가장 오래 머무는 화면은 채팅방이다. 짧은 대화에서는 괜찮았는데, 메시지가 수백 건에서 수천 건까지 쌓인 방에서는 여러 문제가 한꺼번에 드러났다.
- 스크롤할수록 점점 느려진다.
- 오래된 방은 들어가는 순간부터 버벅인다.
- 메시지를 삭제하거나 재생성하면 스크롤이 갑자기 튄다.
- 첫 대화로 이동하려면 한참을 기다려야 한다.
- 심한 경우
Maximum update depth exceeded, 즉React error #185와 함께 화면이 사실상 멈춘다.
처음에는 그냥 메시지가 많아서 느린 문제처럼 보였다. 그런데 메시지 수 자체를 원인으로 놓고 보면 어디부터 손대야 할지가 잘 보이지 않았다. 실제 코드를 따라가 보니 같은 "느림" 안에서도 비용이 커지는 기준이 서로 달랐다.
| 증상 | 비용이 증가하는 기준 |
|---|---|
| AI 답변 한 번에 화면이 멈춘다 | 메시지 개수 × 청크 수 |
| 긴 대화일수록 렌더가 느려진다 | 메시지 개수 |
| 위로 스크롤할수록 더 느려진다 | 누적 DOM 노드 수 |
| 삭제·되돌리기에서 화면이 튄다 | 가상 스크롤 위치 보정 |
| 첫 대화로 이동하는 데 오래 걸린다 | 전체 대화 길이 |
첫 번째 항목은 특히 부담이 컸다. 다른 문제들이 대체로 메시지 수를 따라 커지는 데 비해, 스트리밍 중 렌더 비용은 메시지 개수와 청크 수가 함께 붙어 있었다.
스트리밍이 목록 전체를 건드리고 있었다
AI 답변 하나가 완성될 때까지 서버에서는 수백 번의 청크가 들어온다. 답변 자체도 마크다운 파싱을 거치면서 HTML 태그가 수십 개, 길면 백 개 이상 생긴다. 당시 구조에서는 청크가 도착할 때마다 생성 중인 메시지만 바뀌는 게 아니라 화면에 있는 모든 메시지가 함께 다시 렌더링되고 있었다.
메시지가 100개인 방에서 청크가 300번 오면 렌더는 3만 번이다. 대화와 답변이 길어지면 수십만 번까지 갔고, Maximum update depth exceeded로 화면이 멈추던 현상도 이 경로에서 발생했다.
기존 코드에서 useCompletion은 컨테이너 훅에 있었다.
// 컨테이너 훅
const { complete, completion, isLoading } = useCompletion({
...completionConfig,
onError: handleCompletionError,
onFinish: handleCompletionFinish,
});컨테이너는 이 값으로 임시 메시지를 만들고 기존 메시지 배열 뒤에 붙여 목록으로 내려줬다.
const filteredMessages = useMemo(
() => (localMessage ? [...messages, localMessage] : messages),
[localMessage, messages],
);
<MessageList
messages={filteredMessages}
generatedMessage={generatedMessage}
isGenerating={!!localMessage}
/>목록 안에서도 렌더 시점마다 새 배열을 만들고 있었다.
data={
isGenerating
? [...messages, { role: 'loading', id: 'loading' }]
: suggestions.length > 0
? [...messages, { role: 'suggestions', id: 'suggestions' }]
: messages
}completion이 컴포넌트 트리 위쪽에 있으니 청크 하나가 들어올 때마다 컨테이너부터 다시 렌더됐다. 여기에 data로 전달하는 배열 참조도 매번 새로 만들어졌다. 개별 메시지 컴포넌트에 memo를 걸어둔 상태였지만, 상위에서 새 배열이 계속 내려오는 구조에서는 목록 전체가 이 업데이트 경로에 같이 묶여 있었다.
실제로 일어나던 흐름은 아래와 같았다.
청크 도착
→ completion 갱신 (컨테이너)
→ 컨테이너 리렌더
→ [...messages, localMessage] 새 배열 생성
→ 목록 리렌더
→ 모든 메시지 아이템 리렌더청크 수만큼 이 과정이 반복됐다.
생성 중인 메시지를 목록 밖으로
이 구조를 바꿀 때는 스트리밍 메시지와 확정된 과거 메시지를 같은 data에 두지 않았다.
Virtuoso
├─ data = 서버에서 확정된 과거 메시지만 (스트리밍 중에는 바뀌지 않음)
└─ Footer = 생성 중인 메시지 (StreamingFooterContent)react-virtuoso의 data에는 서버에서 확정된 메시지만 넣고, 생성 중인 메시지는 Footer 슬롯에서 렌더한다. 그리고 useCompletion을 감싼 useChatStream도 컨테이너가 아니라 Footer 컴포넌트 안으로 옮겼다.
export const StreamingFooterContent = memo(function StreamingFooterContent({
characterId,
isBabies,
}: {
characterId: string;
isBabies?: boolean;
}) {
// useCompletion + buffered updates + auto-scroll 모두 여기서 구동
useChatStream(characterId, !isBabies);
// ...
});호출 위치가 바뀌면서 스트리밍 상태의 업데이트 범위도 같이 내려갔다. 청크가 들어와도 Footer 서브트리만 다시 렌더되고, Virtuoso의 과거 메시지 목록은 스트리밍 업데이트에 관여하지 않게 됐다.
스트리밍 중인 텍스트 역시 부모 props로 전달하지 않고 Zustand store에 썼다.
updateStreamContent: (tempId, content) =>
set((state) => ({
messages: state.messages.map((m) =>
m.tempId === tempId ? { ...m, content } : m,
),
})),const localMessages = useLocalMessagesStore((s) =>
s.messages.filter((m) => isVisibleLocalMessage(m, cacheMessageIds)),
);이렇게 두면 스트리밍 텍스트가 바뀔 때 부모를 거쳐 내려갈 필요 없이 해당 store를 구독한 컴포넌트만 업데이트된다.
여기서 Footer 자체의 정체성도 유지해야 했다. Virtuoso에 넘기는 Footer가 렌더마다 새 함수가 되면 슬롯 컴포넌트가 바뀐 것으로 처리되고, Footer가 언마운트됐다가 다시 마운트될 수 있다. 그 안에서 돌고 있는 스트리밍도 같이 끊긴다.
그래서 render prop은 ref에 보관하고 Footer 콜백의 의존성은 스트리밍 중 바뀌지 않는 값으로 제한했다.
const renderFooterRef = useRef(renderFooter);
renderFooterRef.current = renderFooter;
const Footer = useCallback(
() => (
<>{renderFooterRef.current({ hasMessages, isLoading: isPastLoading })}</>
),
[hasMessages, isPastLoading],
);
const components = useMemo(
() => ({ Header, Footer, Scroller }),
[Header, Footer, Scroller],
);의존성은 hasMessages와 isPastLoading 둘뿐이고 둘 다 스트리밍 중에는 변하지 않는다. 부모가 새 renderFooter를 만들어도 ref의 값만 교체되고 Virtuoso에 전달되는 슬롯 컴포넌트는 유지된다.
목록과 스트리밍 메시지를 분리하면서 경계 처리는 조금 더 복잡해졌다. 로컬 메시지가 있으면 실제 화면의 마지막 항목은 Footer이므로 hasLocalMessages로 하단 여백 계산을 보정해야 했다. 생성이 끝난 메시지가 서버 캐시에 들어오는 순간에는 같은 메시지가 로컬과 서버 양쪽에 잠깐 존재할 수 있어서, 서버 ID가 캐시에 있는지 확인해 로컬 렌더 대상에서 제외했다.
또 전송·재생성 콜백은 renderFooterRef와 Virtuoso 슬롯 메모이제이션 체인을 거치면서 stale closure가 될 수 있었다. "현재 생성 중인가"처럼 호출 시점의 값이 필요한 가드는 closure를 믿지 않고 store에서 직접 읽었다.
// 호출 시점의 fresh 상태를 store에서 직접 읽는다.
// 이 callback이 ChatMessageList의 renderFooterRef + Virtuoso 슬롯 메모이제이션
// 체인을 거치며 stale closure로 전달될 수 있어, closure의 isStreaming은 신뢰 X.
if (
useLocalMessagesStore
.getState()
.messages.some((m) => m.status === 'streaming')
) {
// ...
}메모이제이션으로 렌더 경계를 오래 유지할수록 그 안을 통과하는 closure도 오래 살아남을 수 있었다. 그래서 렌더를 끊는 것과 별개로, 최신 상태를 어디서 읽을지는 따로 정해둘 필요가 있었다.
이 변경 뒤에는 청크 하나가 들어왔을 때 다시 그리는 범위가 전체 메시지 목록에서 생성 중인 메시지 하나로 줄었다.
개선 전: 전체 메시지 목록 (메시지 수만큼)
개선 후: 생성 중인 메시지 1개메시지가 100개든 5,000개든 스트리밍 중 렌더 비용은 동일해졌고, Maximum update depth exceeded로 화면이 멈추던 문제도 사라졌다. 렌더 횟수를 줄여놓고 나니 그다음에는 렌더 한 번 안에서 하고 있던 계산이 눈에 들어왔다.
가상화와 렌더 비용은 다른 문제였다
메시지를 그릴 때는 현재 메시지 하나만으로 결정할 수 없는 값들이 있다. 이전 메시지와 발화자가 같은지, 말풍선 꼬리를 붙일지, 현재 메시지가 마지막인지, 재생성에 사용할 promptId가 무엇인지 등을 보려면 배열 안에서 현재 메시지의 위치를 알아야 했다.
기존 코드는 indexOf로 그 위치를 찾고 있었다.
itemContent={(_, message) => {
const dataIndex = messages.indexOf(message);
const prevMsg =
dataIndex > 0 ? messages[dataIndex - 1] : undefined;
// ...
}}이 한 줄은 각 메시지를 렌더할 때마다 실행된다. indexOf는 배열 앞에서부터 원하는 값을 찾을 때까지 순회하므로 항목 하나의 위치를 찾는 비용이 O(n)이다. 여러 메시지에서 같은 탐색을 반복하면 전체 계산은 O(n²)에 가까워진다.
채팅 화면에서는 사용자가 최신 메시지 쪽에 머무는 경우가 많고 최신 메시지는 배열 뒤쪽에 있다. indexOf가 거의 끝까지 순회하기 쉬운 위치다. 스트리밍이 시작되면 이 렌더가 초당 여러 번 반복되기 때문에, 짧은 방에서는 티가 나지 않던 코드가 대화가 길어질수록 부담이 됐다.
react-virtuoso의 itemContent는 첫 번째 인자로 이미 현재 아이템의 인덱스를 넘겨주고 있었다. 기존 코드에서는 그 값을 _로 버리고 다시 찾고 있었다.
- itemContent={(_, message) => {
- const dataIndex = messages.indexOf(message);
+ itemContent={(virtuosoIndex, message) => {
+ const dataIndex =
+ virtuosoIndex - stableFirstItemIndex;
const prevMsg =
dataIndex > 0
? messages[dataIndex - 1]
: undefined;Virtuoso가 준 인덱스를 사용하면서 항목 하나의 위치를 찾는 비용은 O(n)에서 O(1)이 됐다. 전체 목록 관점에서는 O(n²)에 가까웠던 계산이 O(n) 수준으로 내려왔다.
여기서 가상화가 해결하는 범위와 렌더 안의 계산 비용을 분리해서 볼 필요가 있었다. 가상화는 DOM에 올리는 항목 수를 제한해준다. 화면에 20개만 렌더한다고 해도 그 20개를 계산할 때 매번 전체 배열을 훑고 있다면, 데이터가 길어지는 만큼 비용은 계속 커진다.
DOM 쪽 문제도 따로 있었다. 채팅 메시지 하나에는 프로필 이미지, 이름, 말풍선, Markdown, 링크, 이미지, 코드 블록, 액션 버튼 등이 붙는다. AI 답변 하나에 HTML 태그가 수십 개, 길면 백 개 이상 생기기도 한다. 과거 메시지를 가져올 때마다 이 노드를 모두 DOM에 남겨두면 수천 건의 메시지에서 DOM 노드가 수만 개까지 늘어난다.
그래서 목록은 react-virtuoso로 가상화해 데이터가 얼마나 쌓여 있든 실제 DOM에는 현재 뷰포트 주변의 메시지만 남겼다. 대화가 길어져도 DOM 크기는 뷰포트 기준으로 거의 일정하게 유지된다. 이 작업으로 메모리 사용량과 브라우저 레이아웃 비용을 줄였고, indexOf 제거로 화면에 남은 항목을 계산하는 비용도 별도로 낮췄다.
firstItemIndex와 스크롤 위치
가상화를 적용한 뒤에는 삭제나 답변 재생성 때 화면이 위아래로 튀는 문제가 있었다. react-virtuoso의 firstItemIndex를 다루는 방식 때문이었다.
Virtuoso는 firstItemIndex가 감소하면 목록 위쪽에 아이템이 prepend됐다고 해석한다. 과거 메시지를 위에 추가했을 때 현재 보고 있던 메시지가 밀리지 않도록 스크롤 위치를 보정하는 데 필요한 동작이다.
그런데 기존 구현에서는 firstItemIndex를 messages.length에 직접 연결해 두었다. 실제 prepend가 아니어도 메시지 개수가 바뀌면 값이 움직였다. 메시지 삭제, 답변 되돌리기, 재생성, 임시 메시지를 실제 메시지로 교체하는 상황까지 Virtuoso 입장에서는 위쪽에 아이템이 추가된 것처럼 보일 수 있었다.
그래서 길이 변화만 보지 않고 실제로 목록 앞쪽이 바뀌었는지를 같이 확인했다. 메시지 개수가 늘었고 첫 번째 메시지 ID도 달라진 경우에만 prepend로 취급했다.
if (stableIndexRef.current === null || currLen === 0) {
stableIndexRef.current =
START_INDEX - currLen;
} else if (
currLen > prevMsgLenRef.current &&
currFirstId !== prevFirstMsgIdRef.current
) {
const delta =
currLen - prevMsgLenRef.current;
stableIndexRef.current -= delta;
}
firstItemIndex = stableIndexRef.current;채팅 아래쪽에 새 메시지가 추가되면 첫 번째 메시지 ID는 그대로다. 과거 메시지를 위쪽에 붙일 때는 메시지 개수가 늘면서 첫 ID도 달라진다. 이 차이로 실제 prepend를 구분하면서 삭제나 재생성 때 불필요하게 들어가던 스크롤 보정이 사라졌다.
첫 대화까지 중간 페이지를 읽지 않기
렌더링 쪽을 정리한 뒤에는 "맨 위로 이동" 기능도 손봤다. 기존 방식은 버튼을 누르면 첫 메시지가 나올 때까지 과거 메시지 API를 계속 호출하는 구조였다. 대화가 50개라면 별 문제가 없지만 5,000개가 쌓인 방에서는 첫 메시지 하나를 보려고 중간 수천 건을 전부 읽게 된다.
API는 offset 기반이었다.
api.get('/api/messages/...', {
params: {
offset,
limit,
},
});offset은 계속 변하는 채팅 목록에서도 다루기 불편했다. 과거 메시지를 읽는 동안 새 메시지가 생기거나 기존 메시지가 삭제되면 offset=40이 가리키는 위치가 달라질 수 있고, 같은 메시지를 다시 받거나 일부를 건너뛸 가능성이 생긴다.
hasMore도 응답 개수로 클라이언트가 추측하고 있었다.
const hasMore =
data.messages.length === limit;마지막 페이지의 메시지 수가 정확히 limit개면 실제로 다음 데이터가 없어도 요청을 한 번 더 보내게 된다. 그리고 이 API로는 가장 오래된 메시지부터 바로 조회할 방법이 없었다.
그래서 메시지 API를 양방향 cursor 방식으로 바꿨다.
api.get('/api/v2/messages/...', {
params: {
order,
after,
before,
limit,
},
});페이지 상태도 서버 응답에서 직접 받는다.
interface MessageResponse {
messages: MessageObject[];
nextCursor: string;
prevCursor: string;
hasNext: boolean;
hasPrev: boolean;
}order를 추가해서 최신 메시지뿐 아니라 가장 오래된 메시지부터도 조회할 수 있게 했다. 이제 "맨 위로 이동"은 첫 메시지를 만날 때까지 중간 페이지를 순서대로 읽는 작업이 아니라, 오래된 첫 페이지를 한 번 가져오는 작업이 됐다.
한 채팅방을 양쪽 끝에서 읽기
TanStack Query 쪽에서는 같은 채팅방을 DESC와 ASC 두 방향으로 읽는다. 평소 채팅방에 들어오면 기존처럼 최신 메시지부터 가져온다.
const desc =
useInfiniteMessagesV2({
order: 'desc',
});사용자가 "맨 위로"를 누르면 ASC 쿼리를 활성화한다.
const asc =
useInfiniteMessagesV2({
order: 'asc',
enabled: ascEnabled,
});ASC는 가장 오래된 메시지부터 앞으로 오고, DESC는 최신 메시지부터 과거 방향으로 간다. 두 구간을 계속 읽다 보면 어느 시점에 만나게 된다.
이 둘을 합칠 때는 단순히 배열을 이어 붙일 수 없었다. ASC에 1100번 메시지가 있고 DESC에는 49005000번만 있는 상태에서 그대로 합치면 100번 다음에 4900번이 나온다. 아직 가져오지 않은 중간 구간이 화면에서 사라진 것처럼 보인다.
그래서 ASC 구간이 실제로 DESC 구간까지 도달했는지 먼저 확인했다.
const ascReachedDesc = useMemo(() => {
if (
descMessages.length === 0 ||
ascMessages.length === 0
) {
return false;
}
const oldestDescId =
String(descMessages[0]!.id);
return ascMessages.some(
(message) =>
String(message.id) === oldestDescId
);
}, [ascMessages, descMessages]);아직 만나지 않았다면 ASC 구간만 보여준다. 두 구간이 이어진 뒤에만 ID 기준으로 중복을 제거해서 하나의 목록으로 만든다.
if (!ascReachedDesc) {
return ascMessages;
}
const seenIds = new Set(
ascMessages.map((message) =>
String(message.id)
)
);
const uniqueFromDesc =
descMessages.filter(
(message) =>
!seenIds.has(String(message.id))
);
return [
...ascMessages,
...uniqueFromDesc,
].sort((a, b) => a.id - b.id);중간이 비어 있는 두 배열을 하나의 연속된 대화처럼 보이게 만드는 것보다는, 현재 확보한 연속 구간까지만 보여주는 쪽을 택했다.
"맨 위로"를 누른 뒤에야 ASC 요청을 시작하면 네트워크 대기 시간이 짧게라도 드러난다. 그래서 채팅방 진입 시 오래된 메시지의 첫 페이지는 미리 받아두었다.
if (queryClient.getQueryData(ascKey)) {
return;
}
queryClient.prefetchInfiniteQuery({
queryKey: ascKey,
queryFn: () =>
fetchMessagesV2({
order: 'asc',
limit: 500,
}),
initialPageParam: '',
});실제로 "맨 위로" 버튼을 누르는 사용자는 많지 않다. 그래도 첫 페이지 하나를 미리 요청하는 정도라면, 버튼을 눌렀을 때 바로 반응하게 만드는 비용으로 받아들일 수 있었다. TanStack Query 캐시가 있어서 같은 채팅방을 다시 오갈 때는 불필요한 요청도 줄었다.
렌더링 밖에서 반복되던 비용
렌더링 경로를 보다가 SSE 구독 코드도 같이 손보게 됐다. SSE 이벤트 핸들러를 effect dependency에 그대로 넣고 있었고, 핸들러 함수가 렌더마다 새로 만들어지면서 구독도 끊겼다가 다시 연결되고 있었다.
채팅 스트리밍 중에는 렌더 자체가 자주 일어나기 때문에 이 구조에서는 불필요한 재연결도 함께 늘어날 수 있었다. 핸들러는 ref에 보관해서 최신 함수만 교체하고 실제 SSE 연결은 유지하도록 바꿨다. setTimeout도 같은 방식으로 ref에서 관리했고 컴포넌트가 사라질 때 명시적으로 정리했다.
각 코드만 떼어놓으면 큰 비용처럼 보이지 않는다. 다만 채팅방처럼 상태 변경과 네트워크 이벤트가 계속 이어지는 화면에서는 이런 작은 작업이 렌더 횟수만큼 반복되는지가 더 크게 작용했다.
이번 작업에서 계속 확인한 건 "메시지가 많다" 자체가 아니라, 각 코드의 비용이 무엇을 따라 커지고 있는지였다. 스트리밍은 메시지 수와 청크 수에 같이 묶여 있었고, indexOf는 메시지 수를 따라갔으며, DOM은 스크롤하면서 누적됐다. firstItemIndex와 페이지네이션은 렌더 비용과는 다른 방식으로 긴 대화에서 사용성을 깎고 있었다.
여기까지 바꾸고 나니 채팅방이 멈추거나 긴 대화에서 급격히 느려지는 문제는 정리됐지만, AI 답변이 화면에 출력되는 속도 자체는 여전히 고르지 않았다. 다음 편에서는 토큰이 도착하는 속도와 화면에 그리는 속도를 분리했던 RAF 페이서를 다룬다.